feat: Geometry refinement and PBR textures#1
Open
richiejp wants to merge 17 commits into
Open
Conversation
…server
Finishes the stage-1 geometry pipeline end-to-end (image in, 3D mesh out,
pure C++/ggml inference) following the depth-anything.cpp / privacy-filter.cpp
process: layer-by-layer parity validation, sanitizer + fuzz builds, and a Go
demo server with a browser mesh viewer.
New pipeline stages (trellis2.cpp / trellis2.h):
- DINOv3 ViT-L/16 image-conditioning encoder (axial-2D RoPE, LayerScale,
exact-GELU MLP, affine-free final LN) — replaces the external .dinodata
producer. Validated to rel-L2 ≤ 7e-7 across 40 taps.
- PIL-exact fixed-point Lanczos preprocessing (alpha bbox crop, premultiply,
512 resize) — byte-exact vs Pillow.
- Shape-SLAT flow DiT (1.3B sparse DiT over active voxels, 3D RoPE) and the
FlexiDualGrid VAE decoder (sparse ConvNeXt U-Net, 3×3×3 submanifold conv as
27 gather+GEMM, learned subdivision 32³→512³). Decoder numerically exact
through all 4 conv levels; forward rel-L2 2.9e-4.
- flexible_dual_grid.h CPU mesher → triangle mesh (the real TRELLIS.2 geometry).
Converters: convert_dino / convert_slat_flow / convert_shape_dec to GGUF
(shape_slat_normalization baked into KV). scripts/{download_models,convert_all,
refgen,demo}.sh.
Validation: tests/parity.hpp + test_dino / test_preprocess / test_slat,
ctest-registered with SKIP_RETURN_CODE 77 and model/slow labels; ref_common.py
runs the PyTorch reference on stock CUDA/CPU with sparse ops monkeypatched to
dense and TF32 disabled (true-fp32 golden). Existing ref scripts repathed off
the missing trellis2-shiv layout.
Fuzzing: libFuzzer harnesses (fuzz_image, fuzz_dinodata) under ASan/UBSan;
found+fixed a .dinodata shape-product overflow (std::length_error → clean
reject).
Demo: flat C ABI (trellis2_capi) + Go server (purego dlopen, single inference
mutex, job queue, progress) + self-contained WebGL2 viewer. Coarse
(marching-cubes) or fine (dual-grid) path. CUDA via docker CDI; 3D-conv
decoders pinned to CPU (no ggml CUDA CONV_3D kernel).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-Authored-By: Claude Opus 4.8 <[email protected]>
sdpa_auto() keeps the exact materialized-softmax attention while the score matrix stays under 1 GiB — so the whole 512 tier (SS-flow 4096 tokens, SLAT ≤~2300 voxels) is unchanged and still bit-tight (SS-flow 2.4e-4, SLAT 2.9e-4 on CPU, identical to before) — and switches to ggml_flash_attn_ext (tiled online softmax, O(L) memory, F32 accumulation) once it would exceed that. This is the VRAM prerequisite for the 1024_cascade HR stage: benchmarked, the SLAT flow forward now runs at the full 49,152-token cascade cap on the 16 GB GPU, where the exact self-attention matrix alone would be 108 GiB. Flash costs ~3e-3 rel-L2 per forward on the CUDA F16-MMA kernel (immaterial to the mesh); the DINOv3 encoder stays on exact attention (1029 tokens, no memory pressure, kept bit-exact). Co-Authored-By: Claude Opus 4.8 <[email protected]>
Implements the TRELLIS.2 1024_cascade pipeline on top of the 512 tier, reaching the default (sharpest) geometry: a ~5M-vertex 1024^3 dual-grid mesh. Pipeline (mostly reuse — the 1024 shape-SLAT model is architecturally identical to the 512, in_channels 32, no concat_cond): 512 LR flow -> shape_dec.upsample(x4) -> 512^3 coords -> quantize+dedup to the 64^3 HR scaffold -> 1024 HR flow (cond_1024) -> 1024^3 decode -> dual-grid mesh. New/changed: - trellis2.cpp/.h: trellis2_shape_dec_upsample() — the decoder run refactored into a shared shape_dec_run() driver with an upsample_times param + one guarded early-return; the full-decode path is byte-identical (test_slat still PASSES with the same per-level numbers). - trellis2_capi (ABI 1->2): t2_pipeline gains the 1024 model + cascade flag; t2_pipeline_load takes the 1024 gguf + a reserved flags word; t2_generate takes a pipeline_type (auto/coarse/512/1024) and grows the cascade branch (cond_1024, upsample, host quantize+dedup, HR flow, 1024^3 decode); t2_pipeline_caps(). - server + web: quality selector (coarse / 512 / 1024) gated on /api/info caps; -slat-hr / -no-1024 flags; new HR progress stages. - convert_all/download_models: fetch + convert the 1024 checkpoint. Validation (test_cascade, TF32-off reference): upsample coord set and quantized 64^3 HR scaffold match exactly (1 voxel of ~995k); HR (1024-model) flow forward rel-L2 3.1e-4 on CPU. The 1024^3 decode is the same decoder validated exactly at the 512 tier and is exercised end-to-end by the demo (5.1M verts, ~14 GB host-RAM peak); its explicit out7 gate is behind TRELLIS2_CASCADE_DECODE (host-RAM heavy). ref_common: chunk the reference SDPA over query blocks so the true-fp32 math kernel doesn't OOM at HR token counts. Co-Authored-By: Claude Opus 4.8 <[email protected]>
The finest sparse-conv up-block emits a [C_next*8, L] conv output (~8 GB at 1024^3). The old path read it back in full into up_h1 and gathered the surviving children via get_rows in the next level's graph — so the ~8 GB tensor was duplicated (graph compute buffer + host readback), the peak that pushed the 1024 cascade to the edge of host RAM (1 GB free). Since the decoder runs on the CPU backend, the conv output already lives in host memory. shape_dec_run now gathers the surviving children directly from the tensor's data pointer into a compact [C_next, L_child] buffer (with a fallback to a full readback for a non-host backend), and the next level receives the pre-gathered hch/xch instead of doing the get_rows. This is byte-identical to the previous data flow (the gather picks column cidx[j] = C_next contiguous floats at cidx[j]*C_next, exactly what get_rows did). Verified: test_slat passes with identical per-level numbers; the end-to-end 1024 cascade produces the identical 5.1M-vertex mesh; the 1024^3 decode now peaks at ~8 GB free instead of ~1 GB (~7 GB less host RAM). Co-Authored-By: Claude Opus 4.8 <[email protected]>
The FlexiDualGrid VAE decoder was the single biggest fine-path stage
(~49 s, 59 % of a 512 run) and ran on the CPU. Profiling (TRELLIS2_TIMING)
showed the flow DiTs are GPU-bound at ~17 % of tensor-core peak, but the
whole-run "GPU ~30 %" was really the average being dragged down by this
CPU-only decoder.
The decoder is a sparse net (submanifold conv = get_rows + GEMM) that only
used ggml CONCAT/PAD to append a missing-neighbor zero row — and those two
CUDA kernels abort above the 65535 grid limit once the voxel count grows,
which is why it was pinned to the CPU. Replace that with a clamp-index +
0/1-mask formulation (gather a valid row, zero the missing ones): every
remaining op (get_rows, broadcast mul, mul_mat, add, norm, silu) tiles the
voxel dimension fine on CUDA (verified to 3 M voxels), so the decoder now
runs on the GPU in ~2.5 s (512^3) / ~12.5 s (1024^3). Byte-identical to the
old concat path on CPU (test_slat PASS).
Place the decoder by measured free VRAM (trellis2_gpu_free_vram ->
cudaMemGetInfo), not a flag: GPU when the card can hold the tier's decode
after freeing the flow DiTs, else CPU. At decode time ensure_decode_vram
frees the finished flow DiTs if the decode needs the room and reload_flows
brings them back on the next generate — so 512 keeps the flows resident
(no reload) while the big 1024^3 decode transparently frees-and-reloads.
TRELLIS2_SHAPE_DEC_{GPU,CPU} force it; CPU-only builds always pick CPU.
Also make flash attention the default for every flow forward (was gated to
>1 GiB score matrices). Bit-identical to full softmax on CPU (flow parity
still 2.4e-4 / 2.9e-4), ~30 % faster on GPU, and O(L) memory so the exact
path's 805 MB score matrix no longer alloc-fails under VRAM pressure.
TRELLIS2_SDPA_EXACT restores the materialized path.
Net on the 16 GB card, no env vars: 512 fine 85 s -> 39 s; the 1024 cascade
1024^3 decode drops from minutes to 12.5 s. No capi ABI change.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The flexible dual grid emits every quad with a fixed vertex order regardless of the surface-crossing direction, so ~15-20% of faces are wound opposite their neighbours. This is faithful to TRELLIS.2 (its mesh is unoriented too), but a plain signed area-weighted vertex normal cancels at those flips, giving short/noisy normals that render as salt-and-pepper "grain" under one-sided lighting. Compute per-vertex normals from the area-weighted structure tensor A = sum(area * n_hat n_hat^T) and take its dominant eigenvector (power iteration) instead. Because n_hat n_hat^T == (-n_hat)(-n_hat)^T it is immune to winding sign, so it recovers the true surface-normal direction even where signed normals cancel. On a real 512^3 mesh this drops adjacent-normal noise from median 12.0deg / p90 57deg to 7.4deg / 31deg with zero collapsed normals. Cost: +~0.1s at 512^3, +~0.6s at 1024^3 (mesh stage), negligible. The structure-tensor normal is a direction line, not a signed outward normal, so pair it with orientation-independent shading in the WebGL viewer: abs(dot(n,l)) instead of max(dot(n,l),0). This also stops back-wound faces from going dark. Vertex positions and triangles are unchanged (no parity impact); validated end-to-end (512 gen 39s, 1.09M verts, clean render). Co-Authored-By: Claude Opus 4.8 <[email protected]>
The dual-grid mesher splits each quad into two triangles along one of its two diagonals. The reference eval path (FlexiDualGridVaeDecoder -> flexible_dual_grid_to_mesh, train=False) chooses the diagonal from the decoder's learned split_weight = softplus(feat[6]): split 1 when sw0*sw2 > sw1*sw3, else split 2. Our port instead used a geometric best-aligned-normals heuristic (the reference's split_weight=None *fallback*) and ignored feat[6] entirely -- a dead decoder output. Measured on a real 995k-voxel decode, the two criteria pick opposite diagonals on 49.3% of quads, so the shipped tessellation diverged from TRELLIS.2 on nearly half its faces. Follow the learned choice instead: it is byte-faithful to the reference and uses the orientation the network was trained to emit. Surface quality is unchanged (same dual vertices; adjacent-normal noise median 6.3deg vs 5.9deg), so this is a faithfulness fix, not a visual one. No parity test regresses (vertex positions and triangle count are identical). Co-Authored-By: Claude Opus 4.8 <[email protected]>
First step toward the deferred PBR texture path (Trellis2TexturingPipeline),
keeping everything that ships pure C++/ggml. Adds the weight manifest and GGUF
converters for the four new models, all from the same microsoft/TRELLIS.2-4B repo:
* tex_dec (SparseUnetVaeDecoder, out_channels=6 PBR, pred_subdiv=False) --
same U-Net family as the shape decoder; convert_tex_dec_to_gguf.py.
* shape_enc (FlexiDualGridVaeEncoder, in_channels=6 = 3 dual-vertex offsets +
3 intersection flags -> 32-ch latent) -- mirror of the decoder;
convert_shape_enc_to_gguf.py.
* tex_slat_flow 512/1024 (slat_flow_imgshape2tex_dit_1_3B) -- the shape SLAT
flow DiT with concat_cond: in=64 (32 tex-noise + 32 shape-SLAT), out=32.
convert_tex_flow_to_gguf.py emits the shared trellis2-slat-flow arch plus
concat_cond_channels=32 and both normalizations (tex_slat output de-norm,
shape_slat concat-cond in-norm) from texturing_pipeline.json.
download_models.sh + convert_all.sh fetch/convert all four. Verified the GGUFs
carry the right archs, channel dims, concat_cond_channels, and 32-length
normalizations.
Note for the port: the tex decoder is pred_subdiv=False and reconstructs the
shape encoder's exact res-1024 voxel set by replaying the per-level Spatial2Channel
subdivision (threaded via the SparseTensor spatial cache). Encoder and tex
decoder are therefore structurally coupled -- the encoder's subdivision record
must be captured and fed to the decoder.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
scripts/ref_texture.py runs the real Trellis2TexturingPipeline NN stages (shape encoder -> tex-SLAT flow with concat_cond -> tex decoder) on our own generated mesh, with sparse ops monkeypatched to pure torch (ref_common). Needs only o-voxel's CPU mesh->dual-grid built locally -- no nvdiffrast/cumesh/flexgemm. Imports the real o_voxel before ref_common.setup() so its stub guard is skipped. For a fast eyeball it skips the CUDA-only UV bake and trilinear-samples the decoded 6-channel PBR voxels per mesh vertex; render_vcolor.py renders the vertex-coloured result. Validated end-to-end on the 512-tier crown mesh: shape encode -> 2890 latent voxels, 12-step tex flow, decode -> 1.25M PBR voxels, 100% vertex hit-rate, base_color mean [0.61,0.46,0.33] (gold), metallic 0.96, roughness 0.26 -- gold filigree + purple gemstones matching the input. This is the reference the C++ port will reproduce. Co-Authored-By: Claude Opus 4.8 <[email protected]>
Generate per-vertex PBR (base_color, metallic, roughness) end-to-end in the
demo — no Python, no CUDA extensions — reproducing TRELLIS.2's texturing stack.
The insight that kept this small: the shape decoder's own 7-ch dual grid is
exactly the shape encoder's 6-ch input ((1+2m)sigmoid(f)-m, f[3:6]>0), so no
CUDA QEF re-encode is needed; and the tex decoder is the shape decoder with two
deltas (external subdivision replay + 6-ch head). The encoder records
{fine_coords, cidx} per S2C level; the tex decoder replays them via the same
gather, so PBR voxel v == mesh vertex v (direct per-vertex color, no resample).
Ports (all validated to fp16 precision vs PyTorch, ctest -R texture):
* shape_slat_encoder: on-device S2C downsample + per-level subdivision capture
* tex_slat_flow (512/1024): concat_cond sampler variant (normalized shape SLAT
concatenated onto the noise each step; tex sampler params gs=1.0, rescale=0)
* tex_slat_decoder: shape_dec_run with guide subdivisions + PBR [0,1] output
Also refactors dec_load_impl (shared shape/tex decoder loader) and fixes an
argument-evaluation-order hazard the refactor introduced in the geometry decode.
Integration: t2_generate texture stage (lazy-loads the ~4 GB tex models after
freeing the geometry flow DiTs, so they never coexist in VRAM), ABI 2->3,
per-vertex PBR accessors, wire format T2MESH02, and WebGL PBR-ish shading.
Works on both the 512 fine and 1024 cascade paths within 16 GB.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Replace the flat abs-dot term with a lightweight view-space metallic-roughness model: Schlick Fresnel (dielectric F0=0.04, metals tint from base_color), a roughness-driven Blinn-Phong lobe, three studio lights, and a hemispheric ambient + Fresnel environment sheen. Specular now reacts to the orbit (real view vector via a viewModel uniform); all dots keep abs() for the unoriented dual-grid mesh. render_mesh_pbr.py mirrors the same shading for offline eyeballing. Co-Authored-By: Claude Opus 4.8 <[email protected]>
Port o_voxel.postprocess.to_glb to pure C++ (no CUDA): the dense per-vertex-PBR
mesh becomes a portable, image-textured glTF 2.0 binary. Every reference stage
(CuMesh simplify/unwrap/BVH, nvdiffrast raster, flex_gemm grid_sample) has a CPU
replacement in mesh_export.cpp:
meshoptimizer QEM+sloppy decimate -> Taubin smooth + small-component cull
-> UV atlas -> CPU UV-space raster + nearest-dense-vertex PBR bake
-> dilation inpaint -> hand-written glTF.
The dual-grid geometry is machine-generated and heavily non-manifold, which
floors meshopt_simplify and shatters chart unwrappers (xatlas splits a chart at
every non-manifold edge -> tens of thousands of charts, minutes, oversized
atlas). The reference avoids this with CUDA CuMesh mesh repair (fill holes,
repair non-manifold, unify winding) — out of scope here. So the default atlas is
a topology-agnostic per-triangle grid: O(n), never chokes, exactly texture_size,
and faithful because the bake samples the dense source, not the decimated UVs.
xatlas is vendored and available via T2GLB_XATLAS for clean meshes.
Wiring:
- C ABI: t2_bake_glb + t2_free_buffer (raw arrays); ABI 3 -> 4.
- server: engine.BakeGLB binding, /api/glb/{id}?tex=&tris= (cached per params),
viewer "download GLB" button.
- examples/mesh2glb: offline T2MESH -> GLB CLI (no GPU/models).
- third_party/: vendored meshoptimizer + xatlas (both MIT).
- scripts/{render_glb,glb_atlas}.py: GLB render + atlas dump for validation.
Verified end-to-end through the demo: generate -> /api/glb returns a valid glTF;
renders to a correct textured crown, atlas carries faithful base_color / metallic
/ roughness. ~6 s bake, 2048^2 atlas.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
…history
A batch of demo-side quality and UX work, plus CPU mesh cleanup.
Live intermediate previews (ABI 4->5):
- trellis2.cpp: SS flow sampler gains a per-step preview callback that hands
out the denoised x0 estimate; capi decodes it to a 64^3 occupancy and streams
a compact T2VOX01 voxel blob (stride-gated). Settled-occupancy and cascade
HR-scaffold checkpoints emitted too. New t2_preview_fn on t2_generate.
- server: preview purego callback + /api/preview/{id}; job carries previewSeq.
- viewer: instanced-cube voxel renderer + poll-driven swap; "live steps" toggle.
You watch the sparse structure emerge from noise, then the final mesh.
Trackball camera:
- Replace the 2-DOF Euler turntable (gimbal lock, unreachable poses) with an
accumulated-quaternion trackball; upright Z-up->Y-up default (matches the GLB
export); double-click resets.
Shading / viewer:
- Replace the flat 3-light model with a cheap procedural-IBL metallic-roughness
shader (environment reflection blurred by roughness, Fresnel, ACES). Metal
reads as metal; the broad reflection stops specular from "blocking" on facets.
- Face the (unoriented-mesh) normal toward the camera; single-lobe specular.
- Wireframe is now wireframe-only + hidden-line (depth prepass).
Mesh cleanup (examples/flexible_dual_grid.h, applied in capi after extract):
- vertex_normals: resolve the structure-tensor normal's sign consistently via a
parity union-find over edges, so interpolation doesn't cross zero (kills
speckled specular). Winding-unification and Jacobi diffusion were tried and
are worse on this non-manifold mesh.
- drop_small_components: remove floating specks (triangle-only, keeps the vertex
indexing so PBR voxel v == vertex v).
- fill_holes: multi-pass boundary-loop fan-fill of the small holes extract()
leaves; ~90% of clean holes closed. Non-manifold thin-sheet boundaries remain
(needs real repair or higher resolution).
Past-generation history:
- Client-side thumbnail strip (localStorage), click to re-view any past mesh
(server keeps them), per-item remove + clear-all.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Toggling wireframe on a multi-million-triangle generation could blank the
canvas entirely. Two coupled causes:
- Fragile hidden-line depth. The edges were drawn over a colour-masked,
polygon-offset depth prepass using the default LESS test. The effective
offset magnitude is driver-defined, so where it is small (e.g. NVIDIA vs
SwiftShader) the front edges fail the depth test and disappear — and since
the prepass is colour-masked, the result is a blank view. Draw the edges
with LEQUAL so front-surface edges pass reliably while occluded edges
still fail (hidden-line preserved).
- Pathological density. A full per-triangle wireframe of a 9M-triangle mesh
is 27M line segments in a 216 MB index buffer, which can OOM/stall the GPU
and lose the context (also blanking the canvas), and renders as a useless
solid mass anyway (WebGL lines are min 1px). Bound the wire buffer to
~6M segments (48 MB), striding whole triangles above that.
Also add a webglcontextlost handler so a dead GPU context reports 'reload the
page' instead of leaving a silently-black canvas.
Reproduced and verified via headless Chromium against the actual viewer shaders.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This completes the pipeline and adds a demo app. Still WIP